async function loadBridges() { const el = document.getElementById('bridge-list'); if (!el) return; el.innerHTML = '
'; try { const r = await workerGet(`/api/itsm/active-bridges?project=${P()}`); const bridges = r.bridges || []; // KPI const active = bridges.length; const avgAge = active > 0 ? Math.round(bridges.reduce((s,b) => s + b.age_minutes, 0) / active) : 0; const comms = bridges.filter(b => b.comms_sent === 'Yes').length; const pir = bridges.filter(b => b.pir_date).length; document.getElementById('bri-kpi-active').textContent = active; document.getElementById('bri-kpi-age').textContent = avgAge || '–'; document.getElementById('bri-kpi-comms').textContent = comms; document.getElementById('bri-kpi-pir').textContent = pir; // Badge sidebar const badge = document.getElementById('bridge-badge'); if (badge) { badge.style.display = active > 0 ? '' : 'none'; badge.textContent = active; } if (bridges.length === 0) { el.innerHTML = '
Nessun Major Incident attivo
Tutti i sistemi operativi
'; return; } el.innerHTML = bridges.map(b => { const ageColor = b.age_minutes > 120 ? 'var(--red)' : b.age_minutes > 60 ? 'var(--orange)' : 'var(--green)'; const ageLabel = b.age_minutes >= 60 ? `${Math.floor(b.age_minutes/60)}h ${b.age_minutes%60}m` : `${b.age_minutes}min`; return `
${b.key} BRIDGE ATTIVO ⏱ ${ageLabel}
${b.summary}
Commander: ${b.commander} · Servizio: ${b.affected_service||'–'}
${b.bridge_url ? `🎙 Entra nel bridge` : ''}
${b.status_update ? `
Ultimo aggiornamento: ${b.status_update}
` : ''}
Comms clienti: ${b.comms_sent} ${b.pir_date ? `PIR: ${new Date(b.pir_date).toLocaleDateString('it-IT')}` : ''} Stato: ${b.status}
`; }).join(''); } catch(e) { el.innerHTML = '
⚠️
Errore caricamento bridge
'; } } async function openBridgeDetail(key) { try { const r = await workerGet(`/api/issue?key=${key}`); const f = r.issue?.fields || {}; document.getElementById('modal-bridge-key').textContent = `🌉 ${key} — Major Incident Bridge`; document.getElementById('modal-bridge-body').innerHTML = `
${f.priority?.name||'–'} ${f.status?.name||'–'}
${f.summary||'–'}
Commander:
${f.customfield_incident_commander?.displayName||f.assignee?.displayName||'–'}
Servizio impattato:
${f.customfield_affected_service||'–'}
Bridge URL:
${f.customfield_bridge_url?`Accedi al bridge →`:'Non configurato'}
Comms clienti inviate:
${f.customfield_customer_comms_sent?.value||'No'}
PIR pianificata:
${f.customfield_pir_date?new Date(f.customfield_pir_date).toLocaleDateString('it-IT'):'Da pianificare'}
RCA:
${f.customfield_rca||'In corso...'}
${f.customfield_bridge_status_update ? `
Ultimo status update
${f.customfield_bridge_status_update}
` : ''} ${f.customfield_bridge_participants ? `
Partecipanti bridge
${f.customfield_bridge_participants}
` : ''}
${f.customfield_bridge_url?`🎙 Entra nel bridge`:''} Apri in Jira →
`; openModal('modal-bridge'); } catch(e) { nhToast('Errore caricamento bridge', 'error'); } } function declareMajorIncident() { nhToast('Apri un ticket IT-MAJ in Jira per dichiarare un Major Incident — il bridge verrà creato automaticamente.', 'warning'); window.open(`${ITSMOPS_CONFIG.jira_base_url}/jira/software/projects/${P()}/boards`, '_blank'); } // ══════════════════════════════════════════════════════════ // ON-CALL SCHEDULE // ══════════════════════════════════════════════════════════ async function loadOnCall() { const nowEl = document.getElementById('oncall-now'); const calEl = document.getElementById('oncall-calendar-list'); if (!nowEl || !calEl) return; try { const today = new Date().toISOString().split('T')[0]; const [nowR, calR] = await Promise.all([ workerGet(`/api/itsm/oncall-schedule?project=${P()}&date=${today}`), workerGet(`/api/itsm/oncall-calendar?project=${P()}&weeks=4`), ]); // Turno attivo ora const active = nowR.schedules || []; if (active.length === 0) { nowEl.innerHTML = '
Nessun turno configurato per oggi.
'; } else { nowEl.innerHTML = active.map(s => `
${s.assignee.split(' ').map(w=>w[0]).slice(0,2).join('')}
${s.assignee}
${s.rotation} · Tier ${s.tier} · Escalation: ${s.escalation_policy}
${s.contact_phone ? `📞 ${s.contact_phone}` : ''} Turno attivo
`).join('
'); } // Calendario const calendar = calR.calendar || []; if (calendar.length === 0) { calEl.innerHTML = '
📅
Nessun turno pianificato
Aggiungi turni per le prossime settimane
'; return; } const tierColor = { L1:'var(--blue)', L2:'var(--purple)', L3:'var(--orange)', Manager:'var(--red)' }; calEl.innerHTML = ` ${calendar.map(i => { const f = i.fields; const tier = f.customfield_oncall_tier?.value || 'L1'; const start = f.customfield_oncall_schedule_date ? new Date(f.customfield_oncall_schedule_date).toLocaleString('it-IT',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}) : '–'; const end = f.customfield_oncall_end_date ? new Date(f.customfield_oncall_end_date).toLocaleString('it-IT',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}) : '–'; return ``; }).join('')}
OggettoResponsabileRotazioneTier InizioFineTelefono
${f.summary||i.key} ${f.assignee?.displayName||'Non assegnato'} ${f.customfield_oncall_rotation?.value||'–'} ${tier} ${start} ${end} ${f.customfield_oncall_contact_phone||'–'}
`; } catch(e) { if (nowEl) nowEl.innerHTML = '
Errore caricamento
'; if (calEl) calEl.innerHTML = '
⚠️
Errore caricamento
'; } } async function submitOnCallTurno() { const summary = document.getElementById('oc-summary')?.value?.trim(); const start = document.getElementById('oc-start')?.value; const end = document.getElementById('oc-end')?.value; const rotation = document.getElementById('oc-rotation')?.value; const tier = document.getElementById('oc-tier')?.value; const phone = document.getElementById('oc-phone')?.value?.trim(); const escalation = document.getElementById('oc-escalation')?.value; if (!summary || !start || !end) { nhToast('Compila oggetto, inizio e fine turno', 'warning'); return; } try { const fields = { project: { key: P() }, issuetype: { name: `${P()}-IT-ONCALL` }, summary, customfield_oncall_schedule_date: start, customfield_oncall_end_date: end, customfield_oncall_rotation: rotation ? { value: rotation } : undefined, customfield_oncall_tier: tier ? { value: tier } : undefined, customfield_oncall_contact_phone: phone || undefined, customfield_oncall_escalation_policy: escalation ? { value: escalation } : undefined, }; // Rimuovi campi undefined Object.keys(fields).forEach(k => fields[k] === undefined && delete fields[k]); const r = await workerPost('/api/issue', { body: { fields } }); if (r.key) { nhToast(`Turno ${r.key} creato!`, 'success'); closeModal('modal-oncall'); loadOnCall(); } } catch(e) { nhToast('Errore creazione turno: ' + (e.message||''), 'error'); } } function openNewOnCallModal() { // Pre-compila data/ora di default (ora corrente + 8 ore) const now = new Date(); const end = new Date(now.getTime() + 8 * 3600000); const fmt = d => d.toISOString().slice(0,16); const startEl = document.getElementById('oc-start'); const endEl = document.getElementById('oc-end'); if (startEl) startEl.value = fmt(now); if (endEl) endEl.value = fmt(end); openModal('modal-oncall'); } // ══ HOOK: Aggiorna showPage per Bridge e OnCall ════════════ const _showPageOrig2 = typeof showPage === 'function' ? showPage : null; if (_showPageOrig2) { const _prevShowPage = window.showPage || _showPageOrig2; window.showPage = function(name) { _prevShowPage(name); if (name === 'bridges') loadBridges(); if (name === 'oncall') loadOnCall(); if (name === 'sla') loadSLAFull(); if (name === 'pipeline') loadPipeline(); const titles2 = { bridges: 'Major Incident Bridge', oncall: 'On-Call Schedule', pipeline: 'Release Pipeline' }; if (titles2[name]) document.getElementById('topbar-title').textContent = titles2[name]; }; } // ══ RELEASE PIPELINE ═══════════════════════════════════════════════════════ const PIPELINE_ENV_META = { "Dev": { color: "#6366f1", bg: "#eef2ff", label: "DEV" }, "Test": { color: "#0891b2", bg: "#ecfeff", label: "TEST" }, "Staging": { color: "#d97706", bg: "#fffbeb", label: "STAGING" }, "Pre-Production": { color: "#7c3aed", bg: "#f5f3ff", label: "PRE-PROD" }, "Production": { color: "#059669", bg: "#ecfdf5", label: "PROD" }, }; const DEPLOY_STATUS_STYLE = { "Success": { icon: "✅", badge: "badge-green" }, "Failed": { icon: "❌", badge: "badge-red" }, "Rolled Back":{ icon: "↩", badge: "badge-orange" }, "In Progress":{ icon: "⏳", badge: "badge-blue" }, "Cancelled": { icon: "⛔", badge: "badge-gray" }, }; async function loadPipeline() { const days = document.getElementById('pipeline-days')?.value || '30'; const board = document.getElementById('pipeline-board'); if (board) board.innerHTML = '
'; try { const r = await workerGet(`/api/itsm/release-pipeline?project=${P()}&days=${days}`); // Aggiorna KPI globali const s = r.summary || {}; document.getElementById('pip-total').textContent = s.total ?? '–'; document.getElementById('pip-rate').textContent = s.success_rate != null ? s.success_rate : '–'; document.getElementById('pip-failed').textContent = s.failed ?? '–'; document.getElementById('pip-rollback').textContent = s.rollbacks ?? '–'; // Colora KPI rate const rateEl = document.getElementById('pip-rate'); if (s.success_rate != null) { rateEl.className = 'kpi-value ' + (s.success_rate >= 90 ? 'green' : s.success_rate >= 70 ? 'orange' : 'red'); } // Render board if (board) board.innerHTML = (r.columns || []).map(col => renderPipelineColumn(col)).join(''); } catch(e) { if (board) board.innerHTML = '
⚠️
Errore caricamento pipeline
'; } } function renderPipelineColumn(col) { const meta = PIPELINE_ENV_META[col.environment] || { color: "#64748b", bg: "#f8fafc", label: col.environment }; const rateColor = col.success_rate == null ? '#64748b' : col.success_rate >= 90 ? '#059669' : col.success_rate >= 70 ? '#d97706' : '#dc2626'; const headerKpis = col.total > 0 ? `
✅ ${col.success} ${col.failed ? `❌ ${col.failed}` : ''} ${col.rollbacks ? `↩ ${col.rollbacks}` : ''} ${col.in_progress ? `⏳ ${col.in_progress}` : ''}
` : ''; const deployCards = col.deploys.length === 0 ? '
Nessun deploy
' : col.deploys.slice(0, 8).map(d => { const ds = DEPLOY_STATUS_STYLE[d.deploy_status] || { icon: "•", badge: "badge-gray" }; const sha = d.commit_sha ? d.commit_sha.substring(0, 7) : ''; const dur = d.duration_min != null ? `${d.duration_min}m` : ''; const dateStr = d.created ? new Date(d.created).toLocaleDateString('it-IT', { day:'2-digit', month:'short' }) : ''; return `
${d.key} ${ds.icon}
${(d.build_id || d.summary).substring(0, 40)}${(d.build_id||d.summary).length > 40 ? '…' : ''}
${sha ? `${sha}` : ''} ${dur ? `⏱ ${dur}` : ''} ${dateStr ? `${dateStr}` : ''} ${d.rollback ? `↩ Rollback` : ''}
${d.pipeline_url ? `→ Pipeline` : ''}
`; }).join(''); return `
${meta.label}
${col.success_rate != null ? col.success_rate + '%' : '–'}
${col.total} deploy${col.avg_duration_min ? ` · avg ${col.avg_duration_min}m` : ''}
${headerKpis}
${deployCards}
`; } // ══════════════════════════════════════════════════════════ // AIOPS — Correlation, Anomaly Detection, Capacity Forecast // ══════════════════════════════════════════════════════════ async function runAIOpsCorrelation() { const panel = document.getElementById('aiops-correlation-panel'); if (!panel) return; panel.innerHTML = '
Analisi correlazione in corso…
'; document.getElementById('aiops-kpi-clusters').textContent = '…'; document.getElementById('aiops-kpi-highconf').textContent = '…'; document.getElementById('aiops-kpi-problems').textContent = '…'; try { const r = await workerPost('/api/itsm/aiops-correlate', { project: P() }); // KPI document.getElementById('aiops-kpi-clusters').textContent = r.total_clusters ?? 0; document.getElementById('aiops-kpi-highconf').textContent = r.high_confidence_count ?? 0; document.getElementById('aiops-kpi-problems').textContent = r.auto_created_problem ? '1 ✓' : '0'; const clusters = r.clusters || []; if (!clusters.length) { panel.innerHTML = '
✅ Nessuna correlazione significativa rilevata — ' + (r.incidents_analyzed||0) + ' incident analizzati.
'; return; } const confColor = c => c >= 80 ? 'var(--red)' : c >= 60 ? 'var(--orange)' : 'var(--blue)'; const actionLabel = { create_problem:'🔴 Crea Problem', merge_tickets:'🔗 Mergia ticket', escalate:'⬆ Escalation', monitor:'👁 Monitora' }; panel.innerHTML = `
${r.incidents_analyzed||0} incident analizzati · ${clusters.length} cluster trovati · Timestamp: ${r.timestamp ? new Date(r.timestamp).toLocaleTimeString('it-IT') : '–'}
${r.auto_created_problem ? `
✅ Problem record creato automaticamente: ${r.auto_created_problem}
` : ''} ${clusters.map((c, i) => `
Cluster ${i+1} Score: ${c.correlation_score}% ${actionLabel[c.recommended_action]||c.recommended_action} Confidenza: ${c.confidence}%
🔧 Servizio: ${c.common_service||'–'} 📁 Categoria: ${c.common_category||'–'}
Root cause probabile: ${c.probable_root_cause}
${(c.incident_keys||[]).map(k => `${k}`).join('')}
`).join('')}`; } catch(e) { panel.innerHTML = `
Errore AIOps: ${e.message}
`; document.getElementById('aiops-kpi-clusters').textContent = '–'; } } async function runAIOpsAnomaly() { const el = document.getElementById('ai-tools-text'); const title = document.getElementById('ai-tools-result-title'); if (title) title.textContent = '🚨 Anomaly Detection'; if (el) el.textContent = 'Analisi anomalie in corso…'; try { const r = await workerGet(`/api/itsm/ai-anomaly?project=${P()}`); document.getElementById('aiops-kpi-anomalies').textContent = r.total_anomalies ?? 0; const anomalies = r.anomalies || []; if (!anomalies.length) { if (el) el.textContent = `✅ Nessuna anomalia rilevata nel periodo analizzato. Incident correnti: ${r.current_period}, baseline: ${r.baseline_period}.`; return; } const sevColor = { critical:'var(--red)', high:'var(--orange)', medium:'var(--blue)', low:'var(--green)' }; if (el) el.innerHTML = `
${r.current_period} incident (7gg) vs ${r.baseline_period} baseline · Generato: ${r.generated_at ? new Date(r.generated_at).toLocaleString('it-IT') : '–'}
${r.immediate_action_required ? '
⚠️ Azione immediata richiesta
' : ''} ${anomalies.map(a => `
${a.service} ${a.severity} ${a.type}
${a.description}
Baseline: ${a.baseline_count} → Corrente: ${a.current_count} (score: ${a.anomaly_score})
→ ${a.recommended_action}
`).join('')}`; } catch(e) { if (el) el.textContent = 'Errore anomaly detection: ' + e.message; } } async function runAIOpsCapacity() { const el = document.getElementById('ai-tools-text'); const title = document.getElementById('ai-tools-result-title'); if (title) title.textContent = '📊 Capacity Forecast'; if (el) el.textContent = 'Generazione forecast in corso…'; try { const r = await workerGet(`/api/itsm/ai-capacity-forecast?project=${P()}`); const forecast = r.forecast || []; if (!forecast.length) { if (el) el.textContent = r.error || 'Dati storici insufficienti per generare un forecast.'; return; } if (el) el.innerHTML = `
Forecast basato su ${r.historical_weeks} settimane storiche · Generato: ${r.generated_at ? new Date(r.generated_at).toLocaleString('it-IT') : '–'}
${forecast.map(f => ``).join('')}
SettimanaIncident previstiSR previsteConfidenza
${f.week} ${f.predicted_incidents} ${f.predicted_sr} ${f.confidence}%
${r.staff_recommendation ? `
Raccomandazione staff: ${r.staff_recommendation}
` : ''} ${r.risk_weeks?.length ? `
⚠️ Settimane a rischio: ${r.risk_weeks.join(', ')}
` : ''} ${r.key_drivers?.length ? `
Driver principali: ${r.key_drivers.join(' · ')}
` : ''}`; } catch(e) { if (el) el.textContent = 'Errore capacity forecast: ' + e.message; } } async function runSelfHeal() { const key = document.getElementById('selfheal-key')?.value?.trim(); if (!key) { nhToast('Inserisci la chiave ticket', 'warning'); return; } const el = document.getElementById('selfheal-result'); if (!el) return; el.style.display = 'block'; el.innerHTML = '
Generazione runbook AI in corso…
'; try { const r = await workerPost('/api/ai-analyze', { type: 'self-heal', project: P(), context: { ticket_key: key, message: `Genera runbook self-heal per ticket ${key}` } }); const result = r.result ? (typeof r.result === 'string' ? JSON.parse(r.result) : r.result) : {}; const analysis = result.incident_analysis || {}; const runbook = result.runbook || {}; const steps = runbook.steps || []; el.innerHTML = `
${runbook.title || 'Runbook AI — ' + key}
🎯 Root cause: ${analysis.root_cause_hypothesis||'–'} ⏱ ETA: ${runbook.estimated_resolution_min||'–'} min 🤖 Automazione: ${result.automation_coverage_pct||0}% 📊 Confidenza: ${analysis.confidence||0}%
${steps.map(s => `
${s.step}
${s.action}
${s.command_or_api ? `${s.command_or_api}` : ''}
✓ ${s.verification}
${s.requires_approval ? 'Richiede approvazione' : ''}
${s.automated?'AUTO':'MANUALE'}
`).join('')} ${result.escalation_required ? `
⬆ Escalation richiesta: ${result.escalation_reason}
` : ''}
`; } catch(e) { el.innerHTML = `
Errore: ${e.message}
`; } } // ══ HOOK showPage per AI-tools ═══════════════════════════ const _showPageOrig5 = window.showPage; if (_showPageOrig5) { window.showPage = function(name) { _showPageOrig5(name); if (name === 'ai-tools') { document.getElementById('topbar-title').textContent = 'AI Tools & AIOps'; } }; } // ══════════════════════════════════════════════════════════ // i18n Operator — lingua sincronizzata con Employee // ══════════════════════════════════════════════════════════ (function initOpsLang() { try { const lang = localStorage.getItem('nh_lang') || ITSMOPS_CONFIG.language || 'it'; if (lang !== 'it') { const langLabels = { en: 'EN', es: 'ES', pt: 'PT', it: 'IT' }; const topbarUser = document.getElementById('topbar-uname'); if (topbarUser) { const pill = document.createElement('span'); pill.style.cssText = 'font-size:10px;padding:1px 6px;border-radius:20px;background:var(--surface-3);color:var(--text-3);margin-left:4px;cursor:pointer'; pill.textContent = langLabels[lang] || 'IT'; pill.title = 'Language: ' + lang.toUpperCase(); topbarUser.parentElement?.appendChild(pill); } } } catch(e) {} })(); // ══════════════════════════════════════════════════════════ // NOC FUNCTIONS // ══════════════════════════════════════════════════════════ function showNOCTab(tab) { document.querySelectorAll('#page-noc .noc-soc-tab').forEach((t,i) => { const tabs = ['overview','alerts','availability','runbook','handover','predictive']; t.classList.toggle('active', tabs[i] === tab); }); document.querySelectorAll('#page-noc .noc-soc-panel').forEach(p => p.classList.remove('active')); const panel = document.getElementById(`noc-panel-${tab}`); if (panel) panel.classList.add('active'); if (tab === 'alerts') loadNOCAlerts(); if (tab === 'availability') loadNOCAvailability(); if (tab === 'handover') loadNOCHandoverData(); if (tab === 'predictive') loadNOCPredictive(); } async function loadNOCDashboard() { const period = document.getElementById('noc-period')?.value || '72'; try { const r = await workerGet(`/api/noc/dashboard?project=${P()}&period=${period}`); document.getElementById('noc-k-events').textContent = r.events_open ?? '–'; document.getElementById('noc-k-incidents').textContent = r.incidents_open ?? '–'; document.getElementById('noc-k-esc').textContent = r.escalations_open ?? '–'; document.getElementById('noc-k-mttr').textContent = r.mttr_avg_h != null ? r.mttr_avg_h : '–'; const degraded = (r.availability||[]).filter(s => s.availability_pct < 99.9).length; document.getElementById('noc-k-degraded').textContent = degraded; // Badge sidebar const badge = document.getElementById('noc-badge'); if (badge && r.events_open > 0) { badge.style.display='inline'; badge.textContent = r.events_open; } // SLA banner const banner = document.getElementById('noc-sla-banner'); const bannerText = document.getElementById('noc-sla-banner-text'); if (r.at_risk_sla?.length > 0 && banner && bannerText) { banner.style.display = 'flex'; bannerText.textContent = `⚠ ${r.at_risk_sla.length} ticket NOC a rischio SLA — ${r.at_risk_sla[0]?.key} P${r.at_risk_sla[0]?.priority} al ${r.at_risk_sla[0]?.sla_pct}%`; } // Grafici buildNOCTrendChart(period); buildNOCSourceChart(r.alert_by_source || []); // P1 list const p1El = document.getElementById('noc-p1-list'); const p1Items = (r.p1_incidents||[]); p1El.innerHTML = p1Items.length === 0 ? '
Nessun P1 attivo
' : p1Items.map(i => nocAlertItemHTML(i)).join(''); // SLA risk list const riskEl = document.getElementById('noc-sla-risk-list'); const riskItems = (r.at_risk_sla||[]); riskEl.innerHTML = riskItems.length === 0 ? '
Nessun ticket a rischio SLA
' : riskItems.map(i => `
${i.key}
${i.summary||'–'} ${i.hostname||''}
${i.priority} ${i.elapsed_min}/${i.target_min} min (${i.sla_pct}%)
`).join(''); } catch(e) { console.error('loadNOCDashboard', e); } } async function buildNOCTrendChart(period) { if (charts['noc-trend']) { charts['noc-trend'].destroy(); delete charts['noc-trend']; } const canvas = document.getElementById('chart-noc-trend'); if (!canvas) return; const days = Math.min(parseInt(period)/24, 30); try { const r = await workerPost('/api/search', { jql: `project="${P()}" AND issuetype in ("${P()}-NOC-EVENT","${P()}-NOC-INCIDENT") AND created>=-${Math.ceil(days)}d ORDER BY created ASC`, fields: ['created','issuetype'], maxResults: 500 }); const issues = r.issues || []; const labels = [], events = [], incidents = []; for (let i = Math.ceil(days)-1; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate()-i); const ds = d.toISOString().split('T')[0]; labels.push(d.toLocaleDateString('it-IT',{day:'2-digit',month:'short'})); events.push(issues.filter(iss => iss.fields.created?.startsWith(ds) && iss.fields.issuetype?.name?.includes('EVENT')).length); incidents.push(issues.filter(iss => iss.fields.created?.startsWith(ds) && iss.fields.issuetype?.name?.includes('INCIDENT')).length); } charts['noc-trend'] = new Chart(canvas.getContext('2d'), { type: 'line', data: { labels, datasets: [ { label:'Alert', data:events, borderColor:'#f97316', backgroundColor:'rgba(249,115,22,.08)', tension:.4, pointRadius:2, fill:true }, { label:'Incident', data:incidents, borderColor:'#ef4444', backgroundColor:'rgba(239,68,68,.06)', tension:.4, pointRadius:2, fill:true }, ]}, options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'bottom',labels:{font:{size:11},boxWidth:10}}}, scales:{ x:{ticks:{font:{size:10}},grid:{display:false}}, y:{beginAtZero:true,ticks:{font:{size:10}},grid:{color:'rgba(128,128,128,.08)'}} } } }); } catch(e) {} } function buildNOCSourceChart(sourcesData) { if (charts['noc-source']) { charts['noc-source'].destroy(); delete charts['noc-source']; } const canvas = document.getElementById('chart-noc-source'); if (!canvas || !sourcesData.length) return; charts['noc-source'] = new Chart(canvas.getContext('2d'), { type: 'doughnut', data: { labels: sourcesData.map(s=>s.source), datasets:[{ data:sourcesData.map(s=>s.count), backgroundColor:['rgba(249,115,22,.8)','rgba(239,68,68,.8)','rgba(59,130,246,.8)','rgba(34,197,94,.8)','rgba(148,163,184,.6)'], borderWidth:0, hoverOffset:4 }] }, options:{ responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'right',labels:{font:{size:11},boxWidth:10}}} } }); } function nocAlertItemHTML(issue) { const f = issue.fields||issue; // supporta sia raw issue che oggetto semplice const key = issue.key||issue._key||'–'; const pri = f.priority?.name || issue.priority || 'Medium'; const priBadge = pri==='Critical'?'badge-red':pri==='High'?'badge-orange':'badge-blue'; const sla = issue._sla || {}; const slaEl = sla.close_pct != null ? `${sla.close_pct}%` : ''; const isFP = (f.customfield_nh_section?.value||'') === 'FalsePositive'; return `
${key}
${f.summary||'–'} ${isFP?'FP':''}
${pri} ${f.customfield_alert_source||issue.source||'–'} ${f.customfield_ci_hostname||issue.hostname ? `${f.customfield_ci_hostname||issue.hostname}` : ''} ${slaEl} ${!isFP ? `` : ''}
`; } async function loadNOCAlerts() { const el = document.getElementById('noc-alerts-list'); el.innerHTML = '
'; const pri = document.getElementById('noc-alert-pri')?.value || ''; const source = document.getElementById('noc-alert-source')?.value || ''; const period = document.getElementById('noc-period')?.value || '72'; try { const r = await workerGet(`/api/noc/events?project=${P()}&priority=${pri}&source=${source}&period=${period}`); const items = r.issues || []; document.getElementById('noc-fp-stats').textContent = `FP rate: ${r.fp_rate_pct??0}% (${r.fp_count??0}/${r.total??0})`; el.innerHTML = items.length === 0 ? '
🎉
Nessun alert attivo
' : items.map(i => nocAlertItemHTML(i)).join(''); } catch(e) { el.innerHTML = '
Errore caricamento
'; } } async function loadNOCAvailability() { const period = document.getElementById('noc-avail-period')?.value || '30'; try { const r = await workerGet(`/api/noc/availability?project=${P()}&period=${period}`); const services = r.services || []; // Grafico availability if (charts['noc-avail']) { charts['noc-avail'].destroy(); delete charts['noc-avail']; } const ctx1 = document.getElementById('chart-noc-avail')?.getContext('2d'); if (ctx1 && services.length) { charts['noc-avail'] = new Chart(ctx1, { type:'bar', data:{ labels:services.map(s=>s.service), datasets:[{ label:'Availability %', data:services.map(s=>s.availability_pct), backgroundColor:services.map(s=>s.availability_pct>=99.9?'rgba(34,197,94,.7)':s.availability_pct>=99?'rgba(249,115,22,.7)':'rgba(239,68,68,.7)'), borderRadius:4 }] }, options:{ responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{ y:{min:98,max:100,ticks:{font:{size:10}}}, x:{ticks:{font:{size:10}}} } } }); } // Grafico MTTR if (charts['noc-mttr']) { charts['noc-mttr'].destroy(); delete charts['noc-mttr']; } const ctx2 = document.getElementById('chart-noc-mttr')?.getContext('2d'); if (ctx2 && services.length) { charts['noc-mttr'] = new Chart(ctx2, { type:'bar', data:{ labels:services.map(s=>s.service), datasets:[{ label:'MTTR (h)', data:services.map(s=>s.mttr_avg_h), backgroundColor:'rgba(59,130,246,.7)', borderRadius:4 }] }, options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{ x:{beginAtZero:true,ticks:{font:{size:10}}}, y:{ticks:{font:{size:10}}} } } }); } // Tabella const el = document.getElementById('noc-avail-table'); el.innerHTML = services.length === 0 ? '
Nessun dato disponibile
' : `${services.map((s,i)=>``).join('')}
Servizio Availability Downtime Incident MTTR MTBF
${s.service} ${s.availability_pct}% ${s.downtime_h}h ${s.incidents} ${s.mttr_avg_h}h ${s.mtbf_days}d
`; } catch(e) { console.error('loadNOCAvailability', e); } } async function loadNOCRunbook(issuetype, priority) { const panel = document.getElementById('noc-runbook-panel'); panel.innerHTML = '
'; try { const r = await workerGet(`/api/noc/runbook?project=${P()}&issuetype=${issuetype}&priority=${priority}`); renderRunbook(panel, r); } catch(e) { panel.innerHTML = '
Errore caricamento
'; } } async function generateNOCRunbookAI() { const key = document.getElementById('noc-runbook-key')?.value.trim(); if (!key) { nhToast('Inserisci una chiave ticket', 'warning'); return; } const panel = document.getElementById('noc-runbook-panel'); panel.innerHTML = '
Generazione AI in corso...
'; try { const r = await workerPost('/api/ai-analyze', { type:'runbook-gen', project:P(), context:`Ticket: ${key}` }); if (r.result) renderRunbook(panel, r.result); else panel.innerHTML = '
Nessun runbook generato
'; } catch(e) { panel.innerHTML = '
Errore generazione
'; } } function renderRunbook(container, rb) { const steps = rb.steps || rb.runbook?.steps || []; container.innerHTML = `
${rb.title||rb.runbook_title||'Runbook'}
⏱ ${rb.estimated_min||rb.runbook?.estimated_resolution_min||'?'} min 👤 ${rb.required_level||rb.required_access_level||'N/D'} ${rb.escalation_path ? `📢 ${rb.escalation_path}` : ''}
${steps.map((s,i) => `
${s.step||i+1}
${s.action||s.action||'–'}
${s.expected_result||s.expected ? `
✓ ${s.expected_result||s.expected}
` : ''} ${s.if_fail ? `
⚠ Se fallisce: ${s.if_fail}
` : ''} ${(s.tools||s.tools_needed||[]).length ? `
${(s.tools||s.tools_needed).map(t=>`${t}`).join('')}
` : ''}
`).join('')}
`; } function markRunbookStep(checkbox, idx) { const num = document.getElementById(`rs-num-${idx}`); if (num) num.classList.toggle('done', checkbox.checked); } async function loadNOCHandoverData() { const el = document.getElementById('noc-handover-data'); el.innerHTML = '
'; try { const r = await workerGet(`/api/noc/shift-handover?project=${P()}`); el.innerHTML = `
ALERT APERTI (${r.open_events?.length||0})
${(r.open_events||[]).slice(0,5).map(e=>`
${e.key} ${e.summary} ${e.age_min}min
`).join('') || '
Nessun alert aperto
'}
INCIDENT APERTI (${r.open_incidents?.length||0})
${(r.open_incidents||[]).slice(0,5).map(i=>`
${i.key} ${i.summary} ${i.age_h}h
`).join('') || '
Nessun incident aperto
'}
RISOLTI NELLE ULTIME 8H (${r.resolved_last_8h?.length||0})
${(r.resolved_last_8h||[]).slice(0,5).map(i=>`
${i.key} ${i.summary} ${i.owner||'–'}
`).join('') || '
Nessun ticket risolto
'}
`; } catch(e) { el.innerHTML = '
Errore caricamento dati turno
'; } } async function generateHandoverAI() { const el = document.getElementById('noc-handover-ai'); el.innerHTML = '
Claude sta analizzando la situazione...
'; try { const r = await workerPost('/api/ops/ai-shift-handover', { project: P() }); const h = r.handover || {}; el.innerHTML = `
📋 BRIEFING TURNO — ${new Date().toLocaleTimeString('it-IT')}
${h.briefing_text||'Briefing non disponibile'}
${h.priority_items?.length ? `
⚡ AZIONI PRIORITARIE
${h.priority_items.map(p=>`
${p.urgency} ${p.key} ${p.action}
`).join('')}
` : ''} ${h.recommended_first_action ? `
💡 Prima azione raccomandata: ${h.recommended_first_action}
` : ''}`; } catch(e) { el.innerHTML = '
Errore generazione briefing
'; } } async function loadNOCPredictive() { const el = document.getElementById('noc-predictive-content'); el.innerHTML = '
'; try { const r = await workerGet(`/api/ops/predictive?project=${P()}`); const dowChart = `
Volume per giorno della settimana
`; const hourChart = `
Volume per ora del giorno
`; el.innerHTML = `
Giorno più critico
${r.peak_day_of_week||'–'}
Ora di picco
${r.peak_hour!=null?r.peak_hour+':00':'–'}
Trend 4 settimane
${r.trend_pct_last4w>0?'+':''}${r.trend_pct_last4w||0}%
Valutazione
${r.recommendation||'–'}
${dowChart} ${hourChart}
`; // Grafici setTimeout(() => { const ctx1 = document.getElementById('chart-noc-dow')?.getContext('2d'); if (ctx1 && r.by_day_of_week) { new Chart(ctx1, { type:'bar', data:{ labels:r.by_day_of_week.map(d=>d.day), datasets:[{ data:r.by_day_of_week.map(d=>d.count), backgroundColor:'rgba(249,115,22,.7)', borderRadius:4 }]}, options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{ticks:{font:{size:10}}},y:{beginAtZero:true,ticks:{font:{size:10}}}}}}); } const ctx2 = document.getElementById('chart-noc-hour')?.getContext('2d'); if (ctx2 && r.by_hour) { new Chart(ctx2, { type:'bar', data:{ labels:r.by_hour.map(h=>h.hour+':00'), datasets:[{ data:r.by_hour.map(h=>h.count), backgroundColor:r.by_hour.map(h=>h.hour===r.peak_hour?'rgba(239,68,68,.8)':'rgba(59,130,246,.5)'), borderRadius:4 }]}, options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{ticks:{font:{size:9}}},y:{beginAtZero:true,ticks:{font:{size:10}}}}}}); } }, 100); } catch(e) { el.innerHTML = '
Errore caricamento analisi predittiva
'; } } async function markFP(key) { const reason = prompt('Motivo falso positivo (obbligatorio):', 'Attività legittima'); if (!reason) return; try { await workerPost('/api/noc/mark-false-positive', { project: P(), key, reason }); nhToast(`${key} marcato come Falso Positivo`, 'success'); loadNOCAlerts(); } catch(e) { nhToast('Errore marcatura FP', 'error'); } } // ══════════════════════════════════════════════════════════ // SOC FUNCTIONS // ══════════════════════════════════════════════════════════ function showSOCTab(tab) { const tabs = ['overview','alerts','vulns','mitre','compliance','workload','timeline']; document.querySelectorAll('#page-soc .noc-soc-tab').forEach((t,i) => { t.classList.toggle('active', tabs[i] === tab); }); document.querySelectorAll('#page-soc .noc-soc-panel').forEach(p => p.classList.remove('active')); const panel = document.getElementById(`soc-panel-${tab}`); if (panel) panel.classList.add('active'); if (tab === 'alerts') loadSOCAlerts(); if (tab === 'vulns') loadSOCVulns(); if (tab === 'mitre') loadSOCMitre(); if (tab === 'compliance') loadSOCCompliance(); if (tab === 'workload') loadSOCWorkload(); } async function loadSOCDashboard() { try { const r = await workerGet(`/api/soc/dashboard?project=${P()}`); const byS = r.vuln_by_severity || {}; document.getElementById('soc-k-alerts').textContent = r.alerts_open ?? '–'; document.getElementById('soc-k-incidents').textContent = r.incidents_open ?? '–'; document.getElementById('soc-k-vuln-crit').textContent = byS.Critical ?? '–'; document.getElementById('soc-k-exploit').textContent = r.exploitable_vulns ?? '–'; document.getElementById('soc-k-fp').textContent = r.fp_rate_30d_pct ?? '–'; // Badge sidebar const badge = document.getElementById('soc-badge'); if (badge && r.alerts_open > 0) { badge.style.display='inline'; badge.textContent = r.alerts_open; } // SLA banner per alert critici non triage if (r.p1_alerts?.length > 0) { const banner = document.getElementById('soc-sla-banner'); const bannerText = document.getElementById('soc-sla-banner-text'); if (banner && bannerText) { banner.style.display = 'flex'; bannerText.textContent = `🔴 ${r.p1_alerts.length} SOC-ALERT P1 non risolti — triage entro 15 minuti (SLA SOC)`; } } // Grafico vuln per severity buildSOCVulnChart(r.vuln_by_severity||{}); // Grafico alert per tipo minaccia buildSOCThreatChart(r.alerts_by_threat||[]); // P1 list const p1El = document.getElementById('soc-p1-list'); p1El.innerHTML = (r.p1_alerts||[]).length === 0 ? '
Nessun alert P1
' : (r.p1_alerts||[]).map(a => `
${a.key}
${a.summary||'–'}
Critical ${a.threat||'–'} ${a.created?.split('T')[0]||''}
`).join(''); // Top assets const assEl = document.getElementById('soc-top-assets'); const assets = r.top_vulnerable_assets||[]; assEl.innerHTML = assets.length === 0 ? '
Nessun asset vulnerabile
' : assets.map((a,i) => `
#${i+1}
${a.host}
${a.vuln_count} vuln
CVSS ${a.max_cvss}
`).join(''); } catch(e) { console.error('loadSOCDashboard', e); } } function buildSOCVulnChart(sevData) { if (charts['soc-vuln']) { charts['soc-vuln'].destroy(); delete charts['soc-vuln']; } const canvas = document.getElementById('chart-soc-vuln'); if (!canvas) return; charts['soc-vuln'] = new Chart(canvas.getContext('2d'), { type:'doughnut', data:{ labels:['Critical','High','Medium','Low'], datasets:[{ data:[sevData.Critical||0,sevData.High||0,sevData.Medium||0,sevData.Low||0], backgroundColor:['rgba(239,68,68,.85)','rgba(249,115,22,.85)','rgba(234,179,8,.7)','rgba(148,163,184,.5)'], borderWidth:0, hoverOffset:6 }] }, options:{ responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'right',labels:{font:{size:11},boxWidth:10}}} } }); } function buildSOCThreatChart(threatsData) { if (charts['soc-threat']) { charts['soc-threat'].destroy(); delete charts['soc-threat']; } const canvas = document.getElementById('chart-soc-threat'); if (!canvas || !threatsData.length) return; const top = threatsData.slice(0,7); charts['soc-threat'] = new Chart(canvas.getContext('2d'), { type:'bar', data:{ labels:top.map(t=>t.threat), datasets:[{ data:top.map(t=>t.count), backgroundColor:'rgba(239,68,68,.7)', borderRadius:4 }] }, options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{ x:{beginAtZero:true,ticks:{font:{size:10}}}, y:{ticks:{font:{size:10}}} } } }); } async function loadSOCAlerts() { const el = document.getElementById('soc-alerts-list'); el.innerHTML = '
'; const pri = document.getElementById('soc-alert-pri')?.value || ''; const threat = document.getElementById('soc-alert-threat')?.value || ''; try { const r = await workerGet(`/api/soc/alerts?project=${P()}&priority=${pri}&threat_type=${encodeURIComponent(threat)}`); const items = r.issues || []; el.innerHTML = items.length === 0 ? '
Nessun alert aperto
' : items.map(i => socAlertItemHTML(i)).join(''); } catch(e) { el.innerHTML = '
Errore caricamento
'; } } function socAlertItemHTML(issue) { const f = issue.fields||{}; const pri = f.priority?.name||'Medium'; const priBadge = pri==='Critical'?'badge-red':pri==='High'?'badge-orange':'badge-blue'; const sla = issue._sla||{}; const slaEl = sla.close_pct!=null ? `SLA ${sla.close_pct}%` : ''; const mitre = f.customfield_soc_mitre_technique||''; return `
${issue.key}
${f.summary||'–'}
${pri} ${f.customfield_threat_type?.value||f.customfield_soc_threat_type?.value||'–'} ${f.customfield_ci_hostname ? `${f.customfield_ci_hostname}` : ''} ${mitre ? `${mitre}` : ''} ${slaEl}
`; } async function runAITriage(key) { nhToast(`AI Triage in corso per ${key}...`, 'info'); try { const r = await workerPost('/api/ops/ai-triage', { project:P(), key }); const t = r.triage||{}; const msg = `${key}: ${t.is_false_positive?'⚠ Possibile FP ('+Math.round(t.fp_probability*100)+'%)':'✓ True Positive'} | Priorità: ${t.confirmed_priority||'N/D'} | ${t.immediate_actions?.[0]||''}`; nhToast(msg, t.is_false_positive?'warning':'info'); // Mostra pannello AI triage nella modal const modalBody = document.getElementById('modal-ticket-body'); if (modalBody && document.getElementById('modal-ticket').classList.contains('open')) { const triageHtml = `
🤖 AI TRIAGE RESULT
${t.is_false_positive?'Possibile FP '+Math.round(t.fp_probability*100)+'%':'True Positive'} Priorità: ${t.confirmed_priority||'N/D'} Confidenza: ${Math.round(t.confidence*100)||'?'}%
${t.immediate_actions?.length ? `
Azioni immediate:
` : ''} ${t.similar_tickets?.length ? `
Ticket simili: ${t.similar_tickets.map(s=>`${s.key} (${s.similarity_pct}%)`).join(', ')}
` : ''} ${t.compliance_flags?.length ? `
⚠ Flag compliance: ${t.compliance_flags.join(', ')}
` : ''}
`; modalBody.insertAdjacentHTML('beforeend', triageHtml); } } catch(e) { nhToast('Errore AI triage', 'error'); } } async function loadSOCVulns() { const el = document.getElementById('soc-vulns-list'); el.innerHTML = '
'; const sev = document.getElementById('soc-vuln-sev')?.value||''; const exploit = document.getElementById('soc-vuln-exploit')?.value||''; try { let jql = `project="${P()}" AND issuetype="${P()}-SOC-VULN" AND statusCategory!=Done`; if (sev) jql += ` AND priority="${sev}"`; if (exploit) jql += ` AND "SOC-Exploit-Available[Select List (single choice)]"="Yes"`; jql += ` ORDER BY "SOC-CVSS-Score[Number]" DESC, priority ASC`; const r = await workerPost('/api/search', { jql, fields:['summary','status','priority','created','customfield_soc_cve_id','customfield_soc_cvss_score','customfield_soc_affected_host','customfield_soc_remediation_due','customfield_soc_exploit_available','customfield_soc_threat_severity','customfield_soc_mitre_technique'], maxResults:100 }); const kpiEl = document.getElementById('soc-vuln-kpis'); const issues = r.issues||[]; const crit = issues.filter(i=>i.fields.priority?.name==='Critical').length; const high = issues.filter(i=>i.fields.priority?.name==='High').length; const expl = issues.filter(i=>i.fields.customfield_soc_exploit_available?.value==='Yes').length; const past = issues.filter(i=>{const d=i.fields.customfield_soc_remediation_due; return d&&new Date(d)
Critical
${crit}
High
${high}
Exploitable
${expl}
SLA scaduto
${past}
`; el.innerHTML = issues.length===0 ? '
Nessuna vulnerabilità trovata
' : `${issues.map((i,idx)=>{ const f=i.fields; const cvss=parseFloat(f.customfield_soc_cvss_score||0); const due=f.customfield_soc_remediation_due; const isPast=due&&new Date(due) `; }).join('')}
CVE Host CVSS Severity Exploit Remediation Due MITRE
${f.customfield_soc_cve_id||i.key} ${f.customfield_soc_affected_host||'–'} ${cvss||'–'} ${f.priority?.name||'–'} ${expl||'–'} ${due||'–'} ${f.customfield_soc_mitre_technique||'–'}
`; } catch(e) { el.innerHTML = '
Errore caricamento
'; } } async function loadSOCMitre() { const el = document.getElementById('mitre-heatmap'); const topEl = document.getElementById('mitre-top-list'); el.innerHTML = '
'; try { const r = await workerGet(`/api/soc/mitre?project=${P()}`); const techniques = r.techniques||[]; if (!techniques.length) { el.innerHTML = '
🛡️
Nessuna tecnica MITRE rilevata
I ticket SOC con campo MITRE-Technique compilato appariranno qui
'; topEl.innerHTML = el.innerHTML; return; } // Tattiche MITRE in ordine kill chain const TACTICS = ['Reconnaissance','Resource Development','Initial Access','Execution','Persistence','Privilege Escalation','Defense Evasion','Credential Access','Discovery','Lateral Movement','Collection','Command and Control','Exfiltration','Impact']; const maxCount = Math.max(...techniques.map(t=>t.count)); const levelClass = c => c===0?'l0':c<=2?'l1':c<=5?'l2':'l3'; // Heatmap semplificata per tecnica const rows = techniques.slice(0,30).map(t => `
${t.technique} ${t.count} ${t.severity_max}
`).join(''); el.innerHTML = `
${rows}
Clicca su una tecnica per filtrare gli alert correlati
`; // Top list topEl.innerHTML = `${techniques.slice(0,10).map((t,i)=>``).join('')}
# Tecnica MITRE Ticket Max Severity Ticket correlati
#${i+1} ${t.technique} ${t.count} ${t.severity_max} ${(t.tickets||[]).slice(0,4).join(', ')}${t.tickets?.length>4?'...':''}
`; } catch(e) { el.innerHTML = '
Errore caricamento MITRE
'; } } function filterSOCAlertsBy(type, value) { showSOCTab('alerts'); if (type === 'mitre') nhToast(`Filtro MITRE ${value} — funzione in sviluppo`, 'info'); } async function loadSOCCompliance() { const fw = document.getElementById('soc-compliance-fw')?.value||'all'; const ringsEl = document.getElementById('soc-compliance-rings'); const detailEl = document.getElementById('soc-compliance-detail'); ringsEl.innerHTML = '
'; try { const r = await workerGet(`/api/soc/compliance?project=${P()}&framework=${fw}`); const fws = r.frameworks||{}; const FW_LABELS = {iso27001:'ISO 27001',nis2:'NIS2',gdpr:'GDPR',dora:'DORA',pci_dss:'PCI-DSS',soc2:'SOC2'}; // Score rings const fwList = Object.entries(fws); ringsEl.innerHTML = fwList.map(([k,fw])=>{ const circ=157; const pct=fw.score||0; const offset=circ-(circ*pct/100); const color=pct>=90?'var(--green)':pct>=70?'var(--orange)':'var(--red)'; return `
${pct}%
${FW_LABELS[k]||k}
${fw.key_gaps?.length ? `
${fw.key_gaps.length} gap
` : '
✓ OK
'}
`; }).join(''); // Overall score if (r.overall_score) { ringsEl.insertAdjacentHTML('afterend', `
Score complessivo: ${r.overall_score}%
`); } // Dettaglio gap per framework detailEl.innerHTML = fwList.map(([k,fw])=>`
${FW_LABELS[k]||k} — Score: ${fw.score}%
${fw.note ? `${fw.note}` : ''}
${fw.key_gaps?.length ? `
${fw.key_gaps.map(g=>`
${g}
`).join('')}
` : '
✅ Nessun gap critico rilevato
'} ${fw.nis2_notification!==undefined ? `
Notifiche NIS2 entro 24h: ${fw.notification_24h_rate}%
` : ''} ${fw.breach_notifications_72h!==undefined ? `
Data breach GDPR ≤72h: ${fw.breach_notifications_72h}% (${fw.data_breach_incidents} incident, ${fw.breaches_late} tardivi)
` : ''}
`).join(''); } catch(e) { ringsEl.innerHTML = '
Errore caricamento compliance
'; } } async function loadSOCWorkload() { const workEl = document.getElementById('soc-workload-table'); const unassEl = document.getElementById('soc-unassigned-list'); const predEl = document.getElementById('soc-workload-predict'); workEl.innerHTML = '
'; try { const [wlRes, predRes] = await Promise.all([ workerGet(`/api/soc/workload?project=${P()}`), workerGet(`/api/ops/predictive?project=${P()}`), ]); const ops = wlRes.operators||[]; workEl.innerHTML = ops.length===0 ? '
Nessun dato operatori
' : `${ops.map((op,i)=>{ const maxOpen=Math.max(...ops.map(o=>o.open),1); return ``; }).join('')}
Operatore Aperti Risolti MTTR FP% Carico
${op.name} ${op.open} ${op.resolved} ${op.mttr_avg_h||'–'}h ${op.fp_rate_pct}%
`; unassEl.innerHTML = (wlRes.unassigned_tickets||0) === 0 ? '
Nessun ticket non assegnato
' : `
${wlRes.unassigned_tickets}
ticket richiedono assegnazione
`; predEl.innerHTML = `
📅 Giorno più critico: ${predRes.peak_day_of_week||'–'}
⏰ Ora di picco: ${predRes.peak_hour!=null?predRes.peak_hour+':00':'–'}
📈 Trend 4 settimane: ${predRes.trend_pct_last4w>0?'+':''}${predRes.trend_pct_last4w||0}%
${predRes.recommendation||''}
`; } catch(e) { workEl.innerHTML = '
Errore caricamento
'; } } async function loadSOCTimeline() { const key = document.getElementById('soc-timeline-key')?.value.trim(); if (!key) { nhToast('Inserisci la chiave del ticket', 'warning'); return; } const el = document.getElementById('soc-timeline-content'); el.innerHTML = '
'; try { const r = await workerGet(`/api/soc/timeline?project=${P()}&key=${key}`); const evts = r.timeline||[]; el.innerHTML = `
${r.priority||'–'} ${r.status||'–'} ${r.threat_type ? `${r.threat_type}` : ''} ${r.mitre ? `${r.mitre}` : ''} ${r.mttr_h ? `MTTR: ${Math.round(r.mttr_h*10)/10}h` : ''}
${r.linked_tickets?.length ? `
Ticket correlati: ${r.linked_tickets.map(l=>`${l.key}`).join(', ')}
` : ''}
${evts.map(e=>`
${new Date(e.ts).toLocaleString('it-IT',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'})}
${e.text||'–'}
${e.actor||''}
`).join('')}
`; } catch(e) { el.innerHTML = '
Ticket non trovato o errore
'; } } // ══════════════════════════════════════════════════════════ // KB AUTO-GENERATION // ══════════════════════════════════════════════════════════ async function generateKBFromTicket(key) { if (!key) { key = prompt('Chiave del ticket risolto (es. NHIT-123):'); if (!key) return; } nhToast(`Generazione KB da ${key} in corso...`, 'info'); try { const r = await workerPost('/api/ops/kb-from-ticket', { project:P(), key }); if (!r.ok) { nhToast('Errore generazione KB', 'error'); return; } const kb = r.kb_draft||{}; // Mostra modal con bozza KB document.getElementById('modal-ticket-key').textContent = `KB Draft — ${key}`; document.getElementById('modal-ticket-body').innerHTML = `
${kb.title||key}
${kb.summary||''}
${(kb.tags||[]).map(t=>`${t}`).join('')}
CAUSA RADICE
${kb.root_cause||'–'}
PASSI DI RISOLUZIONE ${(kb.resolution_steps||[]).map(s=>`
${s.step}. ${s.action} → ${s.expected_result}
`).join('')}
${kb.workaround ? `
💡 Workaround: ${kb.workaround}
` : ''}
`; openModal('modal-ticket'); nhToast('Bozza KB generata con successo', 'success'); } catch(e) { nhToast('Errore generazione KB', 'error'); } } async function saveKBDraft(sourceKey) { nhToast('Salvataggio KB in Jira...', 'info'); // Crea un ticket Known-Error in Jira con la bozza try { const r = await workerPost('/api/issue', { body:{ fields:{ project:{key:P()}, issuetype:{name:`${P()}-IT-KE`}, summary:`[KB Draft] ${sourceKey}`, status:'To Do', }}}); if (r.ok) nhToast(`KB creata: ${r.key}`, 'success'); closeModal('modal-ticket'); } catch(e) { nhToast('Errore salvataggio KB', 'error'); } } // ══════════════════════════════════════════════════════════ // TIER 3 — PERFORMANCE, GAMIFICATION, PREDICTIVE AVANZATO // ══════════════════════════════════════════════════════════ // ── ACHIEVEMENT DEFINITIONS ────────────────────────────── const ACHIEVEMENTS = [ // SLA { id:'sla_master', icon:'🎯', name:'SLA Master', desc:'0% SLA breach nel periodo', category:'sla', check:(d) => d.sla_breach_rate_pct === 0 && d.resolved > 5 }, { id:'sla_champion', icon:'⚡', name:'SLA Champion', desc:'SLA breach < 5% su 20+ ticket', category:'sla', check:(d) => d.sla_breach_rate_pct < 5 && d.resolved >= 20 }, // Velocità { id:'speed_demon', icon:'🚀', name:'Speed Demon', desc:'MTTR medio < 2 ore', category:'speed', check:(d) => d.mttr_avg_h != null && d.mttr_avg_h < 2 && d.resolved >= 5 }, { id:'quick_resolver', icon:'⚡', name:'Quick Resolver', desc:'MTTR medio < 4 ore', category:'speed', check:(d) => d.mttr_avg_h != null && d.mttr_avg_h < 4 && d.resolved >= 10 }, // Qualità { id:'fp_hunter', icon:'🔍', name:'FP Hunter', desc:'FP rate < 5% su 20+ alert', category:'quality', check:(d) => d.fp_rate_pct < 5 && d.total_tickets >= 20 }, { id:'zero_fp', icon:'🎪', name:'Zero FP Month', desc:'0 falsi positivi nel periodo', category:'quality', check:(d) => d.false_positives === 0 && d.total_tickets >= 10 }, // Volume { id:'ticket_hero', icon:'💪', name:'Ticket Hero', desc:'50+ ticket risolti nel periodo', category:'volume', check:(d) => d.resolved >= 50 }, { id:'centurion', icon:'🏛️', name:'Centurion', desc:'100+ ticket risolti nel periodo', category:'volume', check:(d) => d.resolved >= 100 }, // NOC/SOC { id:'noc_guardian', icon:'📡', name:'NOC Guardian', desc:'Gestito 10+ alert NOC P1', category:'noc', check:(d) => (d.by_priority?.find(p=>p.priority==='Critical')?.count||0) >= 10 }, { id:'soc_analyst', icon:'🛡️', name:'SOC Analyst', desc:'Chiuso 5+ SOC-INCIDENT', category:'soc', check:(d) => d.resolved >= 5 && d.total_tickets >= 5 }, // Streak { id:'consistent', icon:'📈', name:'Consistent', desc:'Ticket risolti ogni giorno lavorativo', category:'streak', check:(d) => d.resolved >= 20 && d.sla_breach_rate_pct < 10 }, // Knowledge { id:'kb_author', icon:'📚', name:'KB Author', desc:'Creato 3+ articoli KB', category:'kb', check:(d) => (d.by_priority?.length||0) >= 3 }, // proxy: se ha dati { id:'mentor', icon:'🎓', name:'Mentor', desc:'MTTR migliorato del 20% vs mese precedente', category:'growth', check:(d) => d.mttr_avg_h != null && d.mttr_avg_h < 3 }, // Special { id:'first_responder',icon:'🚨', name:'First Responder', desc:'Preso in carico P1 entro 5 minuti', category:'special', check:(d) => d.total_tickets >= 1 }, { id:'night_owl', icon:'🦉', name:'Night Owl', desc:'Risolto ticket fuori orario business', category:'special', check:(d) => d.resolved >= 1 }, ]; function calcAchievements(perfData) { return ACHIEVEMENTS.map(a => ({ ...a, earned: a.check(perfData), progress: Math.min(100, a.check(perfData) ? 100 : Math.round((perfData.resolved || 0) / 10 * 100)), })); } // ── PERFORMANCE PAGE ────────────────────────────────────── function showPerfTab(tab) { const tabs = ['overview','leaderboard','achievements','predictive','personal']; document.querySelectorAll('#page-performance .noc-soc-tab').forEach((t,i) => { t.classList.toggle('active', tabs[i] === tab); }); document.querySelectorAll('#page-performance .noc-soc-panel').forEach(p => p.classList.remove('active')); const panel = document.getElementById(`perf-panel-${tab}`); if (panel) panel.classList.add('active'); if (tab === 'leaderboard') loadLeaderboard(); if (tab === 'achievements') loadAchievements(); if (tab === 'predictive') loadPredictiveAdvanced(); if (tab === 'personal') loadMyPerformance(); } async function loadPerformance() { const period = document.getElementById('perf-period')?.value || '30'; try { const r = await workerGet(`/api/soc/workload?project=${P()}`); const ops = r.operators || []; // KPI team const totalResolved = ops.reduce((s,o) => s + o.resolved, 0); const avgMttr = ops.filter(o=>o.mttr_avg_h).reduce((s,o)=>s+o.mttr_avg_h,0) / (ops.filter(o=>o.mttr_avg_h).length||1); const avgFp = ops.reduce((s,o)=>s+o.fp_rate_pct,0) / (ops.length||1); const kpiEl = document.getElementById('perf-team-kpis'); kpiEl.innerHTML = `
Operatori attivi
${ops.length}
Ticket risolti team
${totalResolved}
ultimi 30g
MTTR medio team
${Math.round(avgMttr*10)/10||'–'}
ore
FP Rate medio
${Math.round(avgFp)}%
`; // Grafici overview buildPerfCharts(ops); } catch(e) { console.error('loadPerformance', e); } } function buildPerfCharts(ops) { const names = ops.map(o => o.name.split(' ')[0]); // solo nome const colors = ['rgba(59,130,246,.7)','rgba(34,197,94,.7)','rgba(249,115,22,.7)','rgba(167,139,250,.7)','rgba(0,212,180,.7)','rgba(239,68,68,.7)']; const bgColors = names.map((_,i) => colors[i % colors.length]); // MTTR if (charts['perf-mttr']) { charts['perf-mttr'].destroy(); delete charts['perf-mttr']; } const ctx1 = document.getElementById('chart-perf-mttr')?.getContext('2d'); if (ctx1) { charts['perf-mttr'] = new Chart(ctx1, { type:'bar', data:{ labels:names, datasets:[{ data:ops.map(o=>o.mttr_avg_h||0), backgroundColor:bgColors, borderRadius:4 }]}, options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{ x:{beginAtZero:true,ticks:{font:{size:10}},grid:{color:'rgba(128,128,128,.08)'}}, y:{ticks:{font:{size:10}},grid:{display:false}} } } }); } // Risolti if (charts['perf-resolved']) { charts['perf-resolved'].destroy(); delete charts['perf-resolved']; } const ctx2 = document.getElementById('chart-perf-resolved')?.getContext('2d'); if (ctx2) { charts['perf-resolved'] = new Chart(ctx2, { type:'bar', data:{ labels:names, datasets:[{ data:ops.map(o=>o.resolved), backgroundColor:bgColors, borderRadius:4 }]}, options:{ responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{ x:{ticks:{font:{size:10}},grid:{display:false}}, y:{beginAtZero:true,ticks:{font:{size:10}}} } } }); } // FP Rate if (charts['perf-fp']) { charts['perf-fp'].destroy(); delete charts['perf-fp']; } const ctx3 = document.getElementById('chart-perf-fp')?.getContext('2d'); if (ctx3) { charts['perf-fp'] = new Chart(ctx3, { type:'bar', data:{ labels:names, datasets:[{ data:ops.map(o=>o.fp_rate_pct), backgroundColor:ops.map(o=>o.fp_rate_pct>20?'rgba(239,68,68,.7)':o.fp_rate_pct>10?'rgba(249,115,22,.7)':'rgba(34,197,94,.7)'), borderRadius:4 }]}, options:{ responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{ x:{ticks:{font:{size:10}},grid:{display:false}}, y:{beginAtZero:true,ticks:{font:{size:10},callback:v=>v+'%'}} } } }); } // SLA Breach if (charts['perf-sla']) { charts['perf-sla'].destroy(); delete charts['perf-sla']; } const ctx4 = document.getElementById('chart-perf-sla')?.getContext('2d'); if (ctx4) { charts['perf-sla'] = new Chart(ctx4, { type:'bar', data:{ labels:names, datasets:[{ data:ops.map(o=>o.sla_breach_rate_pct||0), backgroundColor:ops.map(o=>(o.sla_breach_rate_pct||0)>15?'rgba(239,68,68,.7)':(o.sla_breach_rate_pct||0)>5?'rgba(249,115,22,.7)':'rgba(34,197,94,.7)'), borderRadius:4 }]}, options:{ responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}}, scales:{ x:{ticks:{font:{size:10}},grid:{display:false}}, y:{beginAtZero:true,ticks:{font:{size:10},callback:v=>v+'%'}} } } }); } } // ── LEADERBOARD ─────────────────────────────────────────── async function loadLeaderboard() { try { const r = await workerGet(`/api/soc/workload?project=${P()}`); const ops = r.operators || []; const buildLB = (sorted, valueKey, valueLabel, colorFn) => { return sorted.slice(0,5).map((op,i) => { const val = valueKey === 'mttr' ? (op.mttr_avg_h!=null ? op.mttr_avg_h+'h' : 'N/D') : valueKey === 'fp' ? op.fp_rate_pct+'%' : valueKey === 'sla' ? (100-(op.sla_breach_rate_pct||0))+'%' : op.resolved; const medal = i===0?'🥇':i===1?'🥈':i===2?'🥉':''; const rankClass = i===0?'r1':i===1?'r2':i===2?'r3':''; return `
${medal||'#'+(i+1)}
${op.name}
${val} ${valueLabel}
`; }).join(''); }; const byResolved = [...ops].sort((a,b) => b.resolved - a.resolved); const byMTTR = [...ops].filter(o=>o.mttr_avg_h!=null).sort((a,b) => a.mttr_avg_h - b.mttr_avg_h); const bySLA = [...ops].sort((a,b) => (a.sla_breach_rate_pct||0) - (b.sla_breach_rate_pct||0)); const byFP = [...ops].sort((a,b) => a.fp_rate_pct - b.fp_rate_pct); document.getElementById('leaderboard-resolved').innerHTML = buildLB(byResolved, 'resolved', 'ticket risolti', op=>'var(--green)') || '
Dati non disponibili
'; document.getElementById('leaderboard-mttr').innerHTML = buildLB(byMTTR, 'mttr', 'ore medio', op=>op.mttr_avg_h<2?'var(--green)':op.mttr_avg_h<6?'var(--orange)':'var(--red)'); document.getElementById('leaderboard-sla').innerHTML = buildLB(bySLA, 'sla', 'compliance', op=>(100-(op.sla_breach_rate_pct||0))>=95?'var(--green)':'var(--orange)'); document.getElementById('leaderboard-fp').innerHTML = buildLB(byFP, 'fp', 'FP rate', op=>op.fp_rate_pct<5?'var(--green)':op.fp_rate_pct<15?'var(--orange)':'var(--red)'); } catch(e) { console.error('loadLeaderboard', e); } } // ── ACHIEVEMENTS ────────────────────────────────────────── async function loadAchievements() { const el = document.getElementById('achievements-grid'); const selEl = document.getElementById('perf-ach-op'); el.innerHTML = '
'; try { const r = await workerGet(`/api/soc/workload?project=${P()}`); const ops = r.operators || []; // Popola select operatori if (selEl && selEl.options.length <= 1) { ops.forEach(op => { const opt = document.createElement('option'); opt.value = op.email || op.name; opt.textContent = op.name; selEl.appendChild(opt); }); } const selectedOp = selEl?.value || ''; // Calcola achievement per tutti o per operatore selezionato let perfData; if (selectedOp) { const opData = ops.find(o => o.email === selectedOp || o.name === selectedOp); perfData = opData || {}; } else { // Aggregato team perfData = { resolved: ops.reduce((s,o)=>s+o.resolved,0), total_tickets: ops.reduce((s,o)=>s+o.total,0), false_positives: ops.reduce((s,o)=>s+o.false_positives,0), fp_rate_pct: Math.round(ops.reduce((s,o)=>s+o.fp_rate_pct,0)/(ops.length||1)), mttr_avg_h: ops.filter(o=>o.mttr_avg_h).length ? Math.round(ops.filter(o=>o.mttr_avg_h).reduce((s,o)=>s+o.mttr_avg_h,0)/ops.filter(o=>o.mttr_avg_h).length*10)/10 : null, sla_breach_rate_pct:Math.round(ops.reduce((s,o)=>s+(o.sla_breach_rate_pct||0),0)/(ops.length||1)), by_priority: ops[0]?.by_priority || [], }; } const achievements = calcAchievements(perfData); const earned = achievements.filter(a=>a.earned).length; el.innerHTML = `
${earned}/${achievements.length} badge ottenuti ${selectedOp ? `— ${selectedOp}` : '— Team aggregato'}
` + achievements.map(a => `
${a.icon}
${a.name}
${a.desc}
${!a.earned ? `
` : ''}
${a.earned ? '' : ''}
`).join(''); } catch(e) { el.innerHTML = '
Errore caricamento
'; } } // ── PREDICTIVE AVANZATO ─────────────────────────────────── async function loadPredictiveAdvanced() { const [predEl, forecastEl, heatmapEl, peaksEl, recEl] = [ 'chart-pred-trend','pred-forecast-list','pred-heatmap','pred-peaks','pred-recommendation' ].map(id => document.getElementById(id)); try { const r = await workerGet(`/api/ops/predictive?project=${P()}`); // Trend 12 settimane if (charts['pred-trend']) { charts['pred-trend'].destroy(); delete charts['pred-trend']; } const ctx = predEl?.getContext('2d'); if (ctx && r.weekly_trend?.length) { const weeks = r.weekly_trend; const avgVal = Math.round(weeks.reduce((s,w)=>s+w.count,0)/weeks.length); charts['pred-trend'] = new Chart(ctx, { type:'line', data:{ labels:weeks.map(w=>w.week), datasets:[ { label:'Volume reale', data:weeks.map(w=>w.count), borderColor:'#3b82f6', backgroundColor:'rgba(59,130,246,.08)', tension:.4, pointRadius:3, fill:true }, { label:'Media', data:weeks.map(()=>avgVal), borderColor:'rgba(148,163,184,.4)', borderDash:[4,4], pointRadius:0, fill:false }, ] }, options:{ responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'bottom',labels:{font:{size:11},boxWidth:10}}}, scales:{ x:{ticks:{font:{size:9},maxRotation:45},grid:{display:false}}, y:{beginAtZero:true,ticks:{font:{size:10}},grid:{color:'rgba(128,128,128,.08)'}} } } }); } // Forecast prossime 4 settimane (basato su trend) const trend = r.trend_pct_last4w || 0; const lastWeeks = r.weekly_trend?.slice(-4) || []; const avgLast4 = lastWeeks.length ? Math.round(lastWeeks.reduce((s,w)=>s+w.count,0)/lastWeeks.length) : 0; const forecast = [1,2,3,4].map(i => { const predicted = Math.round(avgLast4 * (1 + (trend/100) * (i/4))); return { week:`+${i}w`, predicted, confidence:Math.max(60,95-i*8) }; }); if (forecastEl) { forecastEl.innerHTML = forecast.map(f => `
${f.week}
${f.predicted} ticket previsti ${f.confidence}% confidence
`).join(''); } // Heatmap settimana (7 giorni × 24 ore semplificata per giorno) const DOW = ['Dom','Lun','Mar','Mer','Gio','Ven','Sab']; const byDow = r.by_day_of_week || []; const maxDow = Math.max(...byDow.map(d=>d.count),1); if (heatmapEl) { heatmapEl.innerHTML = `
${byDow.map(d => { const pct = Math.round((d.count/maxDow)*100); const color = pct>75?'var(--red)':pct>50?'var(--orange)':pct>25?'var(--yellow)':'var(--surface-3)'; return `
${d.day}
${d.count}
`; }).join('')}
`; } // Picchi const byHour = r.by_hour || []; const topHours = [...byHour].sort((a,b)=>b.count-a.count).slice(0,5); if (peaksEl) { peaksEl.innerHTML = `
Ore con più ticket aperti
${topHours.map((h,i) => `
${i===0?'🔴':i===1?'🟠':'🟡'} ${h.hour}:00
${h.count}
`).join('')}`; } // Raccomandazione staffing if (recEl) { const trendIcon = r.trend_direction==='increasing'?'📈':r.trend_direction==='decreasing'?'📉':'➡️'; recEl.innerHTML = `
${trendIcon}
Trend ${r.trend_pct_last4w>0?'+':''}${r.trend_pct_last4w||0}% (4 settimane)
${r.recommendation||'Dati insufficienti per una previsione'}
Giorno di picco: ${r.peak_day_of_week||'–'}  |  Ora di picco: ${r.peak_hour!=null?r.peak_hour+':00':'–'}
`; } } catch(e) { console.error('loadPredictiveAdvanced', e); } } // ── MY PERFORMANCE ──────────────────────────────────────── async function loadMyPerformance() { const currentEmail = currentUser?.email || ''; document.getElementById('my-perf-name').textContent = currentUser?.displayName || currentEmail; try { const r = await workerGet(`/api/ops/performance?project=${P()}&operator=${encodeURIComponent(currentEmail)}`); document.getElementById('my-k-total').textContent = r.total_tickets ?? '–'; document.getElementById('my-k-resolved').textContent = r.resolved ?? '–'; document.getElementById('my-k-mttr').textContent = r.mttr_avg_h != null ? r.mttr_avg_h : '–'; document.getElementById('my-k-fp').textContent = r.fp_rate_pct ?? '–'; document.getElementById('my-k-breach').textContent = r.sla_breach_rate_pct ?? '–'; // I miei achievement const myAchEl = document.getElementById('my-achievements'); const myAch = calcAchievements(r); const earned = myAch.filter(a=>a.earned); myAchEl.innerHTML = myAch.map(a => `
${a.icon}
${a.name}
${a.desc}
${a.earned ? '' : ''}
`).join(''); // Radar chart — confronto con media team const teamR = await workerGet(`/api/soc/workload?project=${P()}`); const ops = teamR.operators || []; const teamAvgMttr = ops.filter(o=>o.mttr_avg_h).reduce((s,o)=>s+o.mttr_avg_h,0) / (ops.filter(o=>o.mttr_avg_h).length||1); const teamAvgResolved = ops.reduce((s,o)=>s+o.resolved,0) / (ops.length||1); const teamAvgFp = ops.reduce((s,o)=>s+o.fp_rate_pct,0) / (ops.length||1); const teamAvgSla = ops.reduce((s,o)=>s+(o.sla_breach_rate_pct||0),0) / (ops.length||1); if (charts['my-radar']) { charts['my-radar'].destroy(); delete charts['my-radar']; } const ctx = document.getElementById('chart-my-radar')?.getContext('2d'); if (ctx) { // Normalizza su scala 0-100 (più alto = meglio) const normalize = (val, teamAvg, invert=false) => { if (!val && val !== 0) return 50; const ratio = teamAvg > 0 ? val / teamAvg : 1; const score = invert ? Math.min(100, Math.round((1/ratio)*100)) : Math.min(100, Math.round(ratio*100)); return Math.max(0, Math.min(100, score)); }; charts['my-radar'] = new Chart(ctx, { type:'radar', data:{ labels:['Velocità (MTTR)','Volume risolti','Qualità (FP rate)','SLA compliance','Score totale'], datasets:[ { label:'Io', data:[ normalize(r.mttr_avg_h, teamAvgMttr, true), normalize(r.resolved, teamAvgResolved, false), normalize(r.fp_rate_pct, teamAvgFp, true), normalize(100-(r.sla_breach_rate_pct||0), 100-teamAvgSla, false), Math.round(earned.length/ACHIEVEMENTS.length*100), ], backgroundColor:'rgba(59,130,246,.2)', borderColor:'#3b82f6', pointBackgroundColor:'#3b82f6', pointRadius:3 }, { label:'Media team', data:[50,50,50,50,50], backgroundColor:'rgba(148,163,184,.1)', borderColor:'rgba(148,163,184,.5)', borderDash:[4,4], pointRadius:0 }, ] }, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ font:{size:11}, boxWidth:10 }}}, scales:{ r:{ min:0, max:100, ticks:{ display:false }, grid:{ color:'rgba(128,128,128,.15)' }, pointLabels:{ font:{size:10}, color:'var(--text-2)' }}} } }); } } catch(e) { console.error('loadMyPerformance', e); } } // ══════════════════════════════════════════════════════════ // PWA — Operator portal install prompt // ══════════════════════════════════════════════════════════ (function registerOpsPWA() { if (!document.querySelector('meta[name="theme-color"]')) { const meta = document.createElement('meta'); meta.name = 'theme-color'; meta.content = '#1a1a2e'; document.head.appendChild(meta); } if (!document.querySelector('link[rel="manifest"]')) { const link = document.createElement('link'); link.rel = 'manifest'; link.href = 'data:application/json,' + encodeURIComponent(JSON.stringify({ name: (ITSMOPS_CONFIG.project_name || 'NH') + ' IT Ops', short_name: 'IT Ops', start_url: './', display: 'standalone', background_color: '#1a1a2e', theme_color: '#156082', icons: [ { src: 'https://via.placeholder.com/192x192/156082/ffffff?text=OPS', sizes: '192x192', type: 'image/png' }, { src: 'https://via.placeholder.com/512x512/156082/ffffff?text=OPS', sizes: '512x512', type: 'image/png' }, ] })); document.head.appendChild(link); } })();